🎖️GitЯра🎖️
Commit 2d20cd8a4708e2ef66e98d5259f3a97ec93f240a
Parents : 4846425
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-07-30T11:40:41-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-07-30T16:40:41Z
fix(ui): give rx_snr real presence semantics end to end (#6523)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Changes
46 files changed, 2194 insertions(+), 148 deletions(-)
Diff
diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryOsmMap.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryOsmMap.kt
index 1b7877251c..5d984f01a8 100644
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryOsmMap.kt
+++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryOsmMap.kt
@@ -122,7 +122,7 @@ fun DiscoveryOsmMap(
position = nodeGeoPoint
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
title = node.longName ?: node.shortName ?: "Unknown"
- snippet = "SNR: ${node.snr} dB / RSSI: ${MetricFormatter.rssi(node.rssi)}"
+ snippet = "SNR: ${MetricFormatter.snr(node.snr)} / RSSI: ${MetricFormatter.rssi(node.rssi)}"
val drawableId =
if (node.isSensorNode) {
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionModels.kt b/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionModels.kt
index e2892d4636..de434160ae 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionModels.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionModels.kt
@@ -126,10 +126,10 @@ data class GetNodeDetailsResponse(
val hardwareModel: String,
/** Firmware version string. */
val firmwareVersion: String,
- /** Signal-to-noise ratio of strongest signal. */
- val snr: Float,
- /** Received signal strength indicator in dB. */
- val rssi: Int,
+ /** Signal-to-noise ratio in dB of the strongest signal, or null if this node has no reading. */
+ val snr: Float?,
+ /** Received signal strength indicator in dBm, or null if this node has no reading. */
+ val rssi: Int?,
/** Number of hops away from local node (-1 if unknown). */
val hopsAway: Int,
/** Channel index this node is on. */
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryGoogleMap.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryGoogleMap.kt
index 3af7ebd510..ceae1da74e 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryGoogleMap.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryGoogleMap.kt
@@ -126,7 +126,7 @@ fun DiscoveryGoogleMap(
MarkerComposable(
state = rememberUpdatedMarkerState(position = nodeLatLng),
title = node.longName ?: node.shortName ?: "Unknown",
- snippet = "SNR: ${node.snr} dB / RSSI: ${MetricFormatter.rssi(node.rssi)}",
+ snippet = "SNR: ${MetricFormatter.snr(node.snr)} / RSSI: ${MetricFormatter.rssi(node.rssi)}",
) {
DiscoveryMarkerChip(label = node.shortName ?: "?", color = markerColor, icon = nodeIcon)
}
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt
index a989094ec3..f068487510 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt
@@ -45,7 +45,12 @@ object MetricFormatter {
fun pressure(hPa: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(hPa, decimalPlaces)} hPa"
- fun snr(value: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(value, decimalPlaces)} dB"
+ /**
+ * Formats a signal-to-noise ratio, or [UNKNOWN_VALUE] when the packet carried no measurement. 0 dB is a legitimate
+ * reading, so it must never stand in for a missing one.
+ */
+ fun snr(value: Float?, decimalPlaces: Int = 1): String =
+ if (value == null) UNKNOWN_VALUE else "${NumberFormatter.format(value, decimalPlaces)} dB"
/**
* Formats a received signal strength, or [UNKNOWN_VALUE] when the radio reported none. 0 dBm is a legitimate
diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt
index fe6989e5b4..e13a1a4321 100644
--- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt
+++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt
@@ -74,6 +74,18 @@ class MetricFormatterTest {
@Test
fun snr() {
assertEquals("5.5 dB", MetricFormatter.snr(5.5f))
+ assertEquals("-12.5 dB", MetricFormatter.snr(-12.5f))
+ }
+
+ @Test
+ fun snrAbsentIsUnknown() {
+ assertEquals("—", MetricFormatter.snr(null))
+ }
+
+ @Test
+ fun snrZeroIsARealReading() {
+ // Must not render as unknown: 0 dB is a signal at the noise floor, not a missing measurement.
+ assertEquals("0.0 dB", MetricFormatter.snr(0f))
}
@Test
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt
index 549a858405..a61f633755 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt
@@ -228,8 +228,9 @@ class AiFunctionProviderImpl(
voltage = node.deviceMetrics.voltage,
hardwareModel = node.metadata?.hw_model?.name ?: "Unknown",
firmwareVersion = node.metadata?.firmware_version ?: "Unknown",
- snr = node.snr,
- rssi = node.rssi,
+ // Never surface the unset sentinels to a model — Float.MAX_VALUE reads as a superb signal.
+ snr = node.snrOrNull,
+ rssi = node.rssiOrNull,
hopsAway = node.hopsAway,
channel = node.channel,
lastHeard = node.lastHeard.toLong() * MS_PER_SEC,
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt
index 87619c73e3..9d8b2480f9 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt
@@ -159,10 +159,10 @@ data class NodeDetails(
val hardwareModel: String,
/** Firmware version string. */
val firmwareVersion: String,
- /** Signal-to-noise ratio of the strongest received signal. */
- val snr: Float,
- /** Received signal strength indicator in dB. */
- val rssi: Int,
+ /** Signal-to-noise ratio in dB of the strongest received signal, or null if this node has no reading. */
+ val snr: Float?,
+ /** Received signal strength indicator in dBm, or null if this node has no reading. */
+ val rssi: Int?,
/** Number of hops away from the local node (-1 if unknown). */
val hopsAway: Int,
/** Channel index this node is on. */
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
index 20f83a5c90..71d85950cf 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
@@ -44,6 +44,7 @@ import org.meshtastic.core.model.textMentionsNode
import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.isValidCodePoint
+import org.meshtastic.core.model.util.snrOrNull
import org.meshtastic.core.model.util.toOneLiner
import org.meshtastic.core.repository.AdminPacketHandler
import org.meshtastic.core.repository.DataPair
@@ -250,7 +251,13 @@ class MeshDataHandlerImpl(
// Only actionable beacons (carrying a channel offer) that we haven't already seen warrant a notification.
if (beacon?.offer_channel == null) return
val offer =
- MeshBeaconOffer(fromNodeNum = packet.from, beacon = beacon, snr = packet.rx_snr, rssi = packet.rx_rssi)
+ MeshBeaconOffer(
+ fromNodeNum = packet.from,
+ beacon = beacon,
+ // [MeshBeaconOffer.snr] is not nullable, so absent narrows to 0f. See [snrOrNull].
+ snr = packet.snrOrNull() ?: 0f,
+ rssi = packet.rx_rssi,
+ )
if (meshBeaconRepository.add(offer)) {
radioInterfaceService.launchSessionWork(scope, session) {
notificationManager.dispatch(
@@ -583,7 +590,8 @@ class MeshDataHandlerImpl(
user = fromNode.user,
emoji = emoji,
timestamp = nowMillis,
- snr = packet.rx_snr,
+ // [Reaction.snr] is not nullable, so absent narrows to 0f here. See [snrOrNull].
+ snr = packet.snrOrNull() ?: 0f,
rssi = packet.rx_rssi,
hopsAway =
if (packet.hop_start == 0 || packet.hop_limit > packet.hop_start) {
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
index 305a933992..5a5ec92079 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
@@ -38,6 +38,7 @@ import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.isLora
import org.meshtastic.core.model.util.rxTimeOrNull
+import org.meshtastic.core.model.util.snrOrNull
import org.meshtastic.core.model.util.toOneLineString
import org.meshtastic.core.model.util.toPIIString
import org.meshtastic.core.repository.FromRadioPacketHandler
@@ -319,7 +320,8 @@ class MeshMessageProcessorImpl(
lastHeard = packet.rxTimeOrNull()?.let(::clampTimestampToNow) ?: node.lastHeard,
viaMqtt = viaMqtt,
lastTransport = packet.transport_mechanism.value,
- snr = if (updateRadioMetrics) packet.rx_snr else node.snr,
+ // A packet carrying no snr must not clobber the node's last real reading either.
+ snr = if (updateRadioMetrics) packet.snrOrNull() ?: node.snr else node.snr,
// A packet carrying no rssi must not clobber the node's last real reading.
rssi = if (updateRadioMetrics) packet.rx_rssi ?: node.rssi else node.rssi,
hopsAway = hopsAway,
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt
index 7d7e549a7b..ed010c7bed 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt
@@ -18,6 +18,7 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import org.koin.core.annotation.Single
+import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
@@ -56,7 +57,7 @@ class NeighborInfoHandlerImpl(
ni.neighbors.joinToString("\n") { n ->
val user = nodeRepository.getUser(n.node_id)
val name = "${user.long_name} (${user.short_name})"
- "• $name (SNR: ${n.snr})"
+ "• $name (SNR: ${MetricFormatter.snr(n.snr)})"
}
val fromUser = nodeRepository.getUser(from)
diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/52.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/52.json
new file mode 100644
index 0000000000..179d0783d4
--- /dev/null
+++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/52.json
@@ -0,0 +1,1746 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 52,
+ "identityHash": "4045629645b02d263f278d1140cdec4e",
+ "entities": [
+ {
+ "tableName": "my_node",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))",
+ "fields": [
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "model",
+ "columnName": "model",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "firmwareVersion",
+ "columnName": "firmwareVersion",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "couldUpdate",
+ "columnName": "couldUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shouldUpdate",
+ "columnName": "shouldUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentPacketId",
+ "columnName": "currentPacketId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "messageTimeoutMsec",
+ "columnName": "messageTimeoutMsec",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "minAppVersion",
+ "columnName": "minAppVersion",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "maxChannels",
+ "columnName": "maxChannels",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasWifi",
+ "columnName": "hasWifi",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceId",
+ "columnName": "deviceId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "pioEnv",
+ "columnName": "pioEnv",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "myNodeNum"
+ ]
+ }
+ },
+ {
+ "tableName": "nodes",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `air_quality_metrics` BLOB NOT NULL DEFAULT x'', `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `power_channel_labels` TEXT NOT NULL DEFAULT '[]', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, `last_transport` INTEGER NOT NULL DEFAULT 0, `has_xeddsa_signed` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))",
+ "fields": [
+ {
+ "fieldPath": "num",
+ "columnName": "num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "user",
+ "columnName": "user",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longName",
+ "columnName": "long_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "shortName",
+ "columnName": "short_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastHeard",
+ "columnName": "last_heard",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceTelemetry",
+ "columnName": "device_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channel",
+ "columnName": "channel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viaMqtt",
+ "columnName": "via_mqtt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hops_away",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isFavorite",
+ "columnName": "is_favorite",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isIgnored",
+ "columnName": "is_ignored",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isMuted",
+ "columnName": "is_muted",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "environmentTelemetry",
+ "columnName": "environment_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "powerTelemetry",
+ "columnName": "power_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "airQualityTelemetry",
+ "columnName": "air_quality_metrics",
+ "affinity": "BLOB",
+ "notNull": true,
+ "defaultValue": "x''"
+ },
+ {
+ "fieldPath": "paxcounter",
+ "columnName": "paxcounter",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "public_key",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "notes",
+ "columnName": "notes",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "powerChannelLabels",
+ "columnName": "power_channel_labels",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "manuallyVerified",
+ "columnName": "manually_verified",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "nodeStatus",
+ "columnName": "node_status",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastTransport",
+ "columnName": "last_transport",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "signsPackets",
+ "columnName": "has_xeddsa_signed",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_nodes_last_heard",
+ "unique": false,
+ "columnNames": [
+ "last_heard"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)"
+ },
+ {
+ "name": "index_nodes_short_name",
+ "unique": false,
+ "columnNames": [
+ "short_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)"
+ },
+ {
+ "name": "index_nodes_long_name",
+ "unique": false,
+ "columnNames": [
+ "long_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)"
+ },
+ {
+ "name": "index_nodes_hops_away",
+ "unique": false,
+ "columnNames": [
+ "hops_away"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)"
+ },
+ {
+ "name": "index_nodes_is_favorite",
+ "unique": false,
+ "columnNames": [
+ "is_favorite"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)"
+ },
+ {
+ "name": "index_nodes_last_heard_is_favorite",
+ "unique": false,
+ "columnNames": [
+ "last_heard",
+ "is_favorite"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)"
+ },
+ {
+ "name": "index_nodes_public_key",
+ "unique": false,
+ "columnNames": [
+ "public_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_public_key` ON `${TABLE_NAME}` (`public_key`)"
+ }
+ ]
+ },
+ {
+ "tableName": "packet",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL, `rssi` INTEGER, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0, `message_text` TEXT NOT NULL DEFAULT '', `translated_text` TEXT, `show_translated` INTEGER NOT NULL DEFAULT 0)",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "port_num",
+ "columnName": "port_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "received_time",
+ "columnName": "received_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "read",
+ "columnName": "read",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "1"
+ },
+ {
+ "fieldPath": "data",
+ "columnName": "data",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "packetId",
+ "columnName": "packet_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "routingError",
+ "columnName": "routing_error",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "sfpp_hash",
+ "columnName": "sfpp_hash",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "filtered",
+ "columnName": "filtered",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "messageText",
+ "columnName": "message_text",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "translatedText",
+ "columnName": "translated_text",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "showTranslated",
+ "columnName": "show_translated",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_packet_myNodeNum",
+ "unique": false,
+ "columnNames": [
+ "myNodeNum"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)"
+ },
+ {
+ "name": "index_packet_port_num",
+ "unique": false,
+ "columnNames": [
+ "port_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)"
+ },
+ {
+ "name": "index_packet_contact_key",
+ "unique": false,
+ "columnNames": [
+ "contact_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)"
+ },
+ {
+ "name": "index_packet_contact_key_port_num_received_time",
+ "unique": false,
+ "columnNames": [
+ "contact_key",
+ "port_num",
+ "received_time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)"
+ },
+ {
+ "name": "index_packet_packet_id",
+ "unique": false,
+ "columnNames": [
+ "packet_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
+ },
+ {
+ "name": "index_packet_received_time",
+ "unique": false,
+ "columnNames": [
+ "received_time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_received_time` ON `${TABLE_NAME}` (`received_time`)"
+ },
+ {
+ "name": "index_packet_filtered",
+ "unique": false,
+ "columnNames": [
+ "filtered"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_filtered` ON `${TABLE_NAME}` (`filtered`)"
+ },
+ {
+ "name": "index_packet_read",
+ "unique": false,
+ "columnNames": [
+ "read"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_read` ON `${TABLE_NAME}` (`read`)"
+ }
+ ]
+ },
+ {
+ "tableName": "packet_fts",
+ "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`message_text`, tokenize=`unicode61`, content=`packet`)",
+ "fields": [
+ {
+ "fieldPath": "messageText",
+ "columnName": "message_text",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": []
+ },
+ "ftsVersion": "FTS5",
+ "ftsOptions": {
+ "tokenizer": "unicode61",
+ "tokenizerArgs": [],
+ "contentTable": "packet",
+ "languageIdColumnName": "",
+ "matchInfo": "FTS4",
+ "notIndexedColumns": [],
+ "prefixSizes": [],
+ "preferredOrder": "ASC",
+ "contentRowId": "",
+ "columnSize": true,
+ "detail": "FULL"
+ },
+ "contentSyncTriggers": [
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_UPDATE BEFORE UPDATE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END",
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_DELETE BEFORE DELETE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END",
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_UPDATE AFTER UPDATE ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END",
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_INSERT AFTER INSERT ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END"
+ ]
+ },
+ {
+ "tableName": "contact_settings",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`contact_key`))",
+ "fields": [
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "muteUntil",
+ "columnName": "muteUntil",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastReadMessageUuid",
+ "columnName": "last_read_message_uuid",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastReadMessageTimestamp",
+ "columnName": "last_read_message_timestamp",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "filteringDisabled",
+ "columnName": "filtering_disabled",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "contact_key"
+ ]
+ }
+ },
+ {
+ "tableName": "log",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message_type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "received_date",
+ "columnName": "received_date",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "raw_message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fromNum",
+ "columnName": "from_num",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "portNum",
+ "columnName": "port_num",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "fromRadio",
+ "columnName": "from_radio",
+ "affinity": "BLOB",
+ "notNull": true,
+ "defaultValue": "x''"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_log_from_num",
+ "unique": false,
+ "columnNames": [
+ "from_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)"
+ },
+ {
+ "name": "index_log_port_num",
+ "unique": false,
+ "columnNames": [
+ "port_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)"
+ }
+ ]
+ },
+ {
+ "tableName": "quick_chat",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mode",
+ "columnName": "mode",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uuid"
+ ]
+ }
+ },
+ {
+ "tableName": "reactions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL, `rssi` INTEGER, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))",
+ "fields": [
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "replyId",
+ "columnName": "reply_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "emoji",
+ "columnName": "emoji",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "packetId",
+ "columnName": "packet_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "routingError",
+ "columnName": "routing_error",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "relays",
+ "columnName": "relays",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "relayNode",
+ "columnName": "relay_node",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "to",
+ "columnName": "to",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "channel",
+ "columnName": "channel",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sfpp_hash",
+ "columnName": "sfpp_hash",
+ "affinity": "BLOB"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "myNodeNum",
+ "reply_id",
+ "user_id",
+ "emoji"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_reactions_reply_id",
+ "unique": false,
+ "columnNames": [
+ "reply_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)"
+ },
+ {
+ "name": "index_reactions_packet_id",
+ "unique": false,
+ "columnNames": [
+ "packet_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "metadata",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))",
+ "fields": [
+ {
+ "fieldPath": "num",
+ "columnName": "num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "proto",
+ "columnName": "proto",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_metadata_num",
+ "unique": false,
+ "columnNames": [
+ "num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)"
+ }
+ ]
+ },
+ {
+ "tableName": "device_hardware",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))",
+ "fields": [
+ {
+ "fieldPath": "activelySupported",
+ "columnName": "actively_supported",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "architecture",
+ "columnName": "architecture",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "display_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasInkHud",
+ "columnName": "has_ink_hud",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hasMui",
+ "columnName": "has_mui",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hwModel",
+ "columnName": "hwModel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hwModelSlug",
+ "columnName": "hw_model_slug",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "images",
+ "columnName": "images",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "partitionScheme",
+ "columnName": "partition_scheme",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "platformioTarget",
+ "columnName": "platformio_target",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requiresDfu",
+ "columnName": "requires_dfu",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "supportLevel",
+ "columnName": "support_level",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "tags",
+ "columnName": "tags",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "platformio_target"
+ ]
+ }
+ },
+ {
+ "tableName": "device_link",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`short_code` TEXT NOT NULL, `link_description` TEXT, `is_vendor` INTEGER NOT NULL, `regions` TEXT, `targets` TEXT, PRIMARY KEY(`short_code`))",
+ "fields": [
+ {
+ "fieldPath": "shortCode",
+ "columnName": "short_code",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "linkDescription",
+ "columnName": "link_description",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "isVendor",
+ "columnName": "is_vendor",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "regions",
+ "columnName": "regions",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "targets",
+ "columnName": "targets",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "short_code"
+ ]
+ }
+ },
+ {
+ "tableName": "firmware_release",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pageUrl",
+ "columnName": "page_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseNotes",
+ "columnName": "release_notes",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "zipUrl",
+ "columnName": "zip_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseType",
+ "columnName": "release_type",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "traceroute_node_position",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "logUuid",
+ "columnName": "log_uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requestId",
+ "columnName": "request_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nodeNum",
+ "columnName": "node_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "BLOB",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "log_uuid",
+ "node_num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_traceroute_node_position_log_uuid",
+ "unique": false,
+ "columnNames": [
+ "log_uuid"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)"
+ },
+ {
+ "name": "index_traceroute_node_position_request_id",
+ "unique": false,
+ "columnNames": [
+ "request_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "log",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "log_uuid"
+ ],
+ "referencedColumns": [
+ "uuid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "discovery_session",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `presets_scanned` TEXT NOT NULL, `home_preset` TEXT NOT NULL, `total_unique_nodes` INTEGER NOT NULL DEFAULT 0, `avg_channel_utilization` REAL NOT NULL DEFAULT 0.0, `total_messages` INTEGER NOT NULL DEFAULT 0, `total_sensor_packets` INTEGER NOT NULL DEFAULT 0, `furthest_node_distance` REAL NOT NULL DEFAULT 0.0, `completion_status` TEXT NOT NULL DEFAULT 'complete', `ai_summary` TEXT, `user_latitude` REAL NOT NULL DEFAULT 0.0, `user_longitude` REAL NOT NULL DEFAULT 0.0, `total_dwell_seconds` INTEGER NOT NULL DEFAULT 0, `device_address` TEXT, `home_lora_config` BLOB, `home_primary_channel` BLOB)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "presetsScanned",
+ "columnName": "presets_scanned",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "homePreset",
+ "columnName": "home_preset",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "totalUniqueNodes",
+ "columnName": "total_unique_nodes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "avgChannelUtilization",
+ "columnName": "avg_channel_utilization",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "totalMessages",
+ "columnName": "total_messages",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "totalSensorPackets",
+ "columnName": "total_sensor_packets",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "furthestNodeDistance",
+ "columnName": "furthest_node_distance",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "completionStatus",
+ "columnName": "completion_status",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'complete'"
+ },
+ {
+ "fieldPath": "aiSummary",
+ "columnName": "ai_summary",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "userLatitude",
+ "columnName": "user_latitude",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "userLongitude",
+ "columnName": "user_longitude",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "totalDwellSeconds",
+ "columnName": "total_dwell_seconds",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "deviceAddress",
+ "columnName": "device_address",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "homeLoraConfig",
+ "columnName": "home_lora_config",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "homePrimaryChannel",
+ "columnName": "home_primary_channel",
+ "affinity": "BLOB"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "discovery_preset_result",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `session_id` INTEGER NOT NULL, `preset_name` TEXT NOT NULL, `dwell_duration_seconds` INTEGER NOT NULL DEFAULT 0, `unique_nodes` INTEGER NOT NULL DEFAULT 0, `direct_neighbor_count` INTEGER NOT NULL DEFAULT 0, `mesh_neighbor_count` INTEGER NOT NULL DEFAULT 0, `infrastructure_node_count` INTEGER NOT NULL DEFAULT 0, `message_count` INTEGER NOT NULL DEFAULT 0, `sensor_packet_count` INTEGER NOT NULL DEFAULT 0, `avg_channel_utilization` REAL NOT NULL DEFAULT 0.0, `avg_airtime_rate` REAL NOT NULL DEFAULT 0.0, `packet_success_rate` REAL NOT NULL DEFAULT 0.0, `packet_failure_rate` REAL NOT NULL DEFAULT 0.0, `ai_summary` TEXT, `num_packets_tx` INTEGER NOT NULL DEFAULT 0, `num_packets_rx` INTEGER NOT NULL DEFAULT 0, `num_packets_rx_bad` INTEGER NOT NULL DEFAULT 0, `num_rx_dupe` INTEGER NOT NULL DEFAULT 0, `num_tx_relay` INTEGER NOT NULL DEFAULT 0, `num_tx_relay_canceled` INTEGER NOT NULL DEFAULT 0, `num_online_nodes` INTEGER NOT NULL DEFAULT 0, `num_total_nodes` INTEGER NOT NULL DEFAULT 0, `uptime_seconds` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`session_id`) REFERENCES `discovery_session`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sessionId",
+ "columnName": "session_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "presetName",
+ "columnName": "preset_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dwellDurationSeconds",
+ "columnName": "dwell_duration_seconds",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "uniqueNodes",
+ "columnName": "unique_nodes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "directNeighborCount",
+ "columnName": "direct_neighbor_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "meshNeighborCount",
+ "columnName": "mesh_neighbor_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "infrastructureNodeCount",
+ "columnName": "infrastructure_node_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "messageCount",
+ "columnName": "message_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sensorPacketCount",
+ "columnName": "sensor_packet_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "avgChannelUtilization",
+ "columnName": "avg_channel_utilization",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "avgAirtimeRate",
+ "columnName": "avg_airtime_rate",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "packetSuccessRate",
+ "columnName": "packet_success_rate",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "packetFailureRate",
+ "columnName": "packet_failure_rate",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "aiSummary",
+ "columnName": "ai_summary",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "numPacketsTx",
+ "columnName": "num_packets_tx",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numPacketsRx",
+ "columnName": "num_packets_rx",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numPacketsRxBad",
+ "columnName": "num_packets_rx_bad",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numRxDupe",
+ "columnName": "num_rx_dupe",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numTxRelay",
+ "columnName": "num_tx_relay",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numTxRelayCanceled",
+ "columnName": "num_tx_relay_canceled",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numOnlineNodes",
+ "columnName": "num_online_nodes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numTotalNodes",
+ "columnName": "num_total_nodes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "uptimeSeconds",
+ "columnName": "uptime_seconds",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_discovery_preset_result_session_id",
+ "unique": false,
+ "columnNames": [
+ "session_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_discovery_preset_result_session_id` ON `${TABLE_NAME}` (`session_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "discovery_session",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "session_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "discovered_node",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `preset_result_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `short_name` TEXT, `long_name` TEXT, `neighbor_type` TEXT NOT NULL DEFAULT 'direct', `latitude` REAL, `longitude` REAL, `distance_from_user` REAL, `hop_count` INTEGER NOT NULL DEFAULT 0, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER, `message_count` INTEGER NOT NULL DEFAULT 0, `sensor_packet_count` INTEGER NOT NULL DEFAULT 0, `is_infrastructure` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`preset_result_id`) REFERENCES `discovery_preset_result`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "presetResultId",
+ "columnName": "preset_result_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nodeNum",
+ "columnName": "node_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shortName",
+ "columnName": "short_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "longName",
+ "columnName": "long_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "neighborType",
+ "columnName": "neighbor_type",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'direct'"
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "distanceFromUser",
+ "columnName": "distance_from_user",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "hopCount",
+ "columnName": "hop_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "messageCount",
+ "columnName": "message_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sensorPacketCount",
+ "columnName": "sensor_packet_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isInfrastructure",
+ "columnName": "is_infrastructure",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_discovered_node_preset_result_id",
+ "unique": false,
+ "columnNames": [
+ "preset_result_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_discovered_node_preset_result_id` ON `${TABLE_NAME}` (`preset_result_id`)"
+ },
+ {
+ "name": "index_discovered_node_node_num",
+ "unique": false,
+ "columnNames": [
+ "node_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_discovered_node_node_num` ON `${TABLE_NAME}` (`node_num`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "discovery_preset_result",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "preset_result_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "event_firmware_edition",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`edition` TEXT NOT NULL, `display_name` TEXT NOT NULL, `welcome_message` TEXT NOT NULL, `event_start` TEXT, `event_end` TEXT, `time_zone` TEXT, `location` TEXT, `icon_url` TEXT, `accent_color` TEXT, `tag` TEXT, `domain` TEXT, `theme_json` TEXT, `firmware_json` TEXT, `links_json` TEXT NOT NULL, PRIMARY KEY(`edition`))",
+ "fields": [
+ {
+ "fieldPath": "edition",
+ "columnName": "edition",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "display_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "welcomeMessage",
+ "columnName": "welcome_message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "eventStart",
+ "columnName": "event_start",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "eventEnd",
+ "columnName": "event_end",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "timeZone",
+ "columnName": "time_zone",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "location",
+ "columnName": "location",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "iconUrl",
+ "columnName": "icon_url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "accentColor",
+ "columnName": "accent_color",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "tag",
+ "columnName": "tag",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "domain",
+ "columnName": "domain",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "themeJson",
+ "columnName": "theme_json",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "firmwareJson",
+ "columnName": "firmware_json",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "linksJson",
+ "columnName": "links_json",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "edition"
+ ]
+ }
+ },
+ {
+ "tableName": "merge_marker",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`source_db_name` TEXT NOT NULL, `merged_at` INTEGER NOT NULL, PRIMARY KEY(`source_db_name`))",
+ "fields": [
+ {
+ "fieldPath": "sourceDbName",
+ "columnName": "source_db_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mergedAt",
+ "columnName": "merged_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "source_db_name"
+ ]
+ }
+ },
+ {
+ "tableName": "channel_set",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `channel_set` BLOB NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channelSet",
+ "columnName": "channel_set",
+ "affinity": "BLOB",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4045629645b02d263f278d1140cdec4e')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
index 705ebcb6d0..cee617cd8a 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
@@ -130,8 +130,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 48, to = 49),
AutoMigration(from = 49, to = 50),
AutoMigration(from = 50, to = 51),
+ AutoMigration(from = 51, to = 52),
],
- version = 51,
+ version = 52,
exportSchema = true,
)
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
index 99ab2c19d9..5ece3d4ac4 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
@@ -102,7 +102,8 @@ data class Packet(
@ColumnInfo(name = "data") val data: DataPacket,
@ColumnInfo(name = "packet_id", defaultValue = "0") val packetId: Int = 0,
@ColumnInfo(name = "routing_error", defaultValue = "-1") var routingError: Int = -1,
- @ColumnInfo(name = "snr", defaultValue = "0") val snr: Float = 0f,
+ /** Null when the packet carried no snr. Rows written before schema 52 store 0 for both absent and 0 dB. */
+ @ColumnInfo(name = "snr") val snr: Float? = null,
/** Null when the radio reported no rssi. Rows written before schema 51 store 0 for both absent and 0 dBm. */
@ColumnInfo(name = "rssi") val rssi: Int? = null,
@ColumnInfo(name = "hopsAway", defaultValue = "-1") val hopsAway: Int = -1,
@@ -162,7 +163,8 @@ data class ReactionEntity(
@ColumnInfo(name = "user_id") val userId: String,
val emoji: String,
val timestamp: Long,
- @ColumnInfo(name = "snr", defaultValue = "0") val snr: Float = 0f,
+ /** Null when the packet carried no snr. Rows written before schema 52 store 0 for both absent and 0 dB. */
+ @ColumnInfo(name = "snr") val snr: Float? = null,
/** Null when the radio reported no rssi. Rows written before schema 51 store 0 for both absent and 0 dBm. */
@ColumnInfo(name = "rssi") val rssi: Int? = null,
@ColumnInfo(name = "hopsAway", defaultValue = "-1") val hopsAway: Int = -1,
diff --git a/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/MeshtasticDatabaseMigrationTest.kt b/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/MeshtasticDatabaseMigrationTest.kt
index b5bd8d60f7..508eebe724 100644
--- a/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/MeshtasticDatabaseMigrationTest.kt
+++ b/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/MeshtasticDatabaseMigrationTest.kt
@@ -108,6 +108,29 @@ class MeshtasticDatabaseMigrationTest {
}
}
+ @Test
+ fun snrColumnsGoNullableWithoutLosingRows() = runTest {
+ helper.createDatabase(SNR_NULLABLE_FROM_VERSION).use { connection ->
+ connection.execSQL(
+ "INSERT INTO packet (uuid, myNodeNum, port_num, contact_key, received_time, read, data, snr, rssi) " +
+ "VALUES (1, 42, 1, '0^all', 1000, 1, '{}', 0.0, -70)",
+ )
+ connection.execSQL(
+ "INSERT INTO reactions (myNodeNum, reply_id, user_id, emoji, timestamp, snr, rssi) " +
+ "VALUES (42, 7, '!abc', 'X', 2000, -12.5, -70)",
+ )
+ }
+
+ helper.runMigrationsAndValidate(SNR_NULLABLE_TO_VERSION, emptyList()).use { connection ->
+ // A stored 0 dB must survive the recreate as 0, not become NULL: it is a real reading.
+ assertEquals(listOf("0.0"), queryColumn(connection, "SELECT snr FROM packet"))
+ assertEquals(listOf("-12.5"), queryColumn(connection, "SELECT snr FROM reactions"))
+ // A NULL is now storable where the column was previously NOT NULL DEFAULT 0.
+ connection.execSQL("UPDATE packet SET snr = NULL WHERE uuid = 1")
+ assertEquals(listOf(null), queryColumn(connection, "SELECT snr FROM packet"))
+ }
+ }
+
/** Reads one column of every row as a string, with SQL NULL surfaced as Kotlin null. */
private fun queryColumn(connection: SQLiteConnection, sql: String): List<String?> =
connection.prepare(sql).use { statement ->
@@ -130,5 +153,7 @@ class MeshtasticDatabaseMigrationTest {
const val EARLIEST_SCHEMA_VERSION = 3
const val RSSI_NULLABLE_FROM_VERSION = 50
const val RSSI_NULLABLE_TO_VERSION = 51
+ const val SNR_NULLABLE_FROM_VERSION = 51
+ const val SNR_NULLABLE_TO_VERSION = 52
}
}
diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt
index 1759027caa..977240e027 100644
--- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt
+++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt
@@ -23,6 +23,7 @@ import okio.BufferedSink
import org.koin.core.annotation.Single
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.positionToMeter
+import org.meshtastic.core.model.util.snrOrNull
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.proto.PortNum
@@ -76,9 +77,12 @@ constructor(
}
}
+ // Rows are limited to receptions that carried an SNR measurement. Gating on `snrOrNull()` rather than
+ // `rx_snr != 0f` keeps a genuine 0 dB reading in the export.
+ val rxSnrOrNull = proto.snrOrNull()
if (
(filterPortnum == null || (proto.decoded?.portnum?.value ?: 0) == filterPortnum) &&
- proto.rx_snr != 0.0f
+ rxSnrOrNull != null
) {
val timeZone = TimeZone.currentSystemDefault()
val rxDateTimeObj = Instant.fromEpochMilliseconds(packet.received_date).toLocalDateTime(timeZone)
@@ -97,7 +101,7 @@ constructor(
val rxLat = rxPos?.latitude ?: ""
val rxLong = rxPos?.longitude ?: ""
val rxAlt = rxPos?.altitude ?: ""
- val rxSnr = proto.rx_snr
+ val rxSnr = rxSnrOrNull
val dist =
if (senderPos == null || rxPos == null) {
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt
index cc0fe001a8..b290c47db4 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt
@@ -51,7 +51,8 @@ data class DataPacket(
var channel: Int = 0, // channel index
var wantAck: Boolean = true, // If true, the receiver should send an ack back
var hopStart: Int = 0,
- var snr: Float = 0f,
+ /** Signal-to-noise ratio in dB, or null when the packet carried no measurement. 0 dB is a valid reading. */
+ var snr: Float? = null,
/** Received signal strength, or null when the radio did not report one. 0 dBm is a valid reading. */
var rssi: Int? = null,
var replyId: Int? = null, // If this is a reply to a previous message, this is the ID of that message
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshBeaconOffer.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshBeaconOffer.kt
index 0390b3c882..6d45adb7e3 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshBeaconOffer.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshBeaconOffer.kt
@@ -27,10 +27,15 @@ import org.meshtastic.proto.MeshBeacon
*
* @param fromNodeNum The node that broadcast the beacon (informational only — beacons are unsigned).
* @param beacon The decoded advertisement, carrying the display [message][MeshBeacon.message] and the join offer.
- * @param snr Signal-to-noise ratio of the received beacon packet, in dB (0 when unknown).
+ * @param snr Signal-to-noise ratio of the received beacon packet, in dB, or null when the radio reported none.
* @param rssi Received signal strength of the beacon packet, in dBm, or null when the radio reported none.
*/
-data class MeshBeaconOffer(val fromNodeNum: Int, val beacon: MeshBeacon, val snr: Float = 0f, val rssi: Int? = null) {
+data class MeshBeaconOffer(
+ val fromNodeNum: Int,
+ val beacon: MeshBeacon,
+ val snr: Float? = null,
+ val rssi: Int? = null,
+) {
/** Stable identity for dedup/dismiss: a given sender advertising a given channel is one standing invitation. */
val key: String
get() = "$fromNodeNum:${beacon.offer_channel?.name.orEmpty()}"
@@ -55,9 +60,10 @@ data class MeshBeaconOffer(val fromNodeNum: Int, val beacon: MeshBeacon, val snr
/**
* Inverse of [encode]; returns null for a structurally malformed record (wrong field count, unparseable node
- * number, or an undecodable beacon payload). An unparseable snr falls back to 0 and an unparseable rssi to
- * absent — they are non-critical display metrics, not identity, so a bad numeric there does not discard an
- * otherwise-valid invitation. An absent rssi encodes as `null`, which [String.toIntOrNull] round-trips back.
+ * number, or an undecodable beacon payload). An unparseable snr or rssi falls back to absent — they are
+ * non-critical display metrics, not identity, so a bad numeric there does not discard an otherwise-valid
+ * invitation. An absent value encodes as `null`, which [String.toFloatOrNull]/[String.toIntOrNull] round-trip
+ * back to null.
*/
@Suppress("ReturnCount")
fun decode(record: String): MeshBeaconOffer? {
@@ -66,7 +72,7 @@ data class MeshBeaconOffer(val fromNodeNum: Int, val beacon: MeshBeacon, val snr
val node = parts[0].toIntOrNull() ?: return null
val beaconBytes = parts.last().decodeBase64()?.toByteArray() ?: return null
val beacon = runCatching { MeshBeacon.ADAPTER.decode(beaconBytes) }.getOrNull() ?: return null
- return MeshBeaconOffer(node, beacon, parts[1].toFloatOrNull() ?: 0f, parts[2].toIntOrNull())
+ return MeshBeaconOffer(node, beacon, parts[1].toFloatOrNull(), parts[2].toIntOrNull())
}
}
}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt
index 7b98e19377..2e5fafb5e4 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt
@@ -160,7 +160,8 @@ data class Message(
val routingError: Int,
val packetId: Int,
val emojis: List<Reaction>,
- val snr: Float,
+ /** Signal-to-noise ratio in dB, or null when the packet carried no measurement. 0 dB is a valid reading. */
+ val snr: Float?,
/** Received signal strength, or null when the radio did not report one. 0 dBm is a valid reading. */
val rssi: Int?,
val hopsAway: Int,
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NeighborInfo.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NeighborInfo.kt
index 3eac6ade30..ea806a353b 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NeighborInfo.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NeighborInfo.kt
@@ -17,6 +17,7 @@
package org.meshtastic.core.model
import co.touchlab.kermit.Logger
+import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
@@ -43,7 +44,7 @@ fun NeighborInfo.getNeighborInfoResponse(getUser: (nodeNum: Int) -> String, head
append("• ")
append(getUser(n.node_id))
append(" (SNR: ")
- append(n.snr)
+ append(MetricFormatter.snr(n.snr))
append(")\n")
}
}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt
index 02f54367f3..9f794e1be1 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt
@@ -92,6 +92,20 @@ data class Node(
val mismatchKey
get() = (publicKey ?: user.public_key) == ERROR_BYTE_STRING
+ /**
+ * Last measured SNR in dB, or null when this node has no reading yet ([snr] still holds [SNR_UNSET]).
+ *
+ * Every read of [snr] should go through this: 0 dB is a real, good reading, and the raw sentinel rates as an
+ * *excellent* signal if it reaches the preset-relative quality bands. Threshold comparisons such as `snr < 100f`
+ * are not equivalent — they also discard any genuine reading at or above the threshold.
+ */
+ val snrOrNull: Float?
+ get() = snr.takeIf { it != SNR_UNSET }
+
+ /** Last measured RSSI in dBm, or null when this node has no reading yet. 0 dBm is a real reading. */
+ val rssiOrNull: Int?
+ get() = rssi.takeIf { it != RSSI_UNSET }
+
val hasEnvironmentMetrics: Boolean
get() = environmentMetrics != EnvironmentMetrics()
@@ -178,6 +192,14 @@ data class Node(
/** Size (in bytes) of a Curve25519 public key as used by meshtastic firmware. */
const val PUBLIC_KEY_SIZE: Int = 32
+ /**
+ * Sentinels stored when a node has no radio-metric reading. They exist because [snr]/[rssi] are not nullable
+ * (the Room columns behind them are NOT NULL); resolve them with [snrOrNull]/[rssiOrNull] rather than comparing
+ * against them at call sites.
+ */
+ const val SNR_UNSET: Float = Float.MAX_VALUE
+ const val RSSI_UNSET: Int = Int.MAX_VALUE
+
val ERROR_BYTE_STRING: ByteString = ByteArray(PUBLIC_KEY_SIZE) { 0 }.toByteString()
fun getRelayNode(relayNodeId: Int, nodes: List<Node>, ourNodeNum: Int?): Node? {
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Reaction.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Reaction.kt
index 6b336b2e09..8233333429 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Reaction.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Reaction.kt
@@ -24,7 +24,10 @@ data class Reaction(
val user: User,
val emoji: String,
val timestamp: Long,
- val snr: Float,
+ /**
+ * Signal-to-noise ratio in dB, or null when the packet carried no measurement (locally sent reactions included).
+ */
+ val snr: Float?,
/** Received signal strength, or null when the radio did not report one (locally sent reactions included). */
val rssi: Int?,
val hopsAway: Int,
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt
index 586902bd82..31eb0baa2e 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt
@@ -104,10 +104,26 @@ fun MeshPacket.isLora(): Boolean = transport_mechanism == MeshPacket.TransportMe
* Arrival time in epoch seconds, or null when the radio had no clock at reception.
*
* Firmware that gained explicit presence omits the field; older firmware still sends 0 for the same state. Both mean
- * unknown — a 1970 arrival time is never a genuine reading.
+ * unknown — a 1970 arrival time is never a genuine reading. Folding 0 is safe here for exactly that reason; see
+ * [snrOrNull] for the fields where it is not.
*/
fun MeshPacket.rxTimeOrNull(): Int? = rx_time?.takeIf { it != 0 }
+/**
+ * Signal-to-noise ratio in dB for this reception, or null when the packet carries no SNR measurement (it did not arrive
+ * over LoRa, or the radio reported none).
+ *
+ * Deliberately does NOT fold 0 the way [rxTimeOrNull] does: 0 dB is a genuine, common reading — a signal at the noise
+ * floor, comfortably demodulable on every preset — so treating it as "absent" would hide real measurements and, worse,
+ * discard the only zero that can ever reach us. Under proto3 implicit presence a field at its zero value is never put
+ * on the wire, so an SNR-less packet from firmware predating the optional conversion already decodes to null for free.
+ * A 0 that survives to this accessor was written explicitly and means 0 dB.
+ *
+ * Presence cannot be inferred from [isLora] instead: `transport_mechanism` defaults to `TRANSPORT_INTERNAL` (0), so
+ * firmware that never sets it would have every reading suppressed.
+ */
+fun MeshPacket.snrOrNull(): Float? = rx_snr
+
/** Returns true if this packet is a direct LoRa signal (not MQTT, and hop count matches). */
fun MeshPacket.isDirectSignal(): Boolean =
rxTimeOrNull() != null && hop_start == hop_limit && via_mqtt != true && isLora()
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt
index 24833b0fa5..0298e22435 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt
@@ -44,7 +44,7 @@ open class MeshDataMapper(private val nodeIdLookup: NodeIdLookup) {
channel = if (packet.pki_encrypted == true) NodeAddress.PKC_CHANNEL_INDEX else packet.channel,
wantAck = packet.want_ack == true,
hopStart = packet.hop_start,
- snr = packet.rx_snr,
+ snr = packet.snrOrNull(),
rssi = packet.rx_rssi,
replyId = decoded.reply_id,
relayNode = packet.relay_node,
diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SnrExtensionsTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SnrExtensionsTest.kt
new file mode 100644
index 0000000000..4a34b38d81
--- /dev/null
+++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SnrExtensionsTest.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.model.util
+
+import org.meshtastic.proto.MeshPacket
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+
+/**
+ * Guards the presence policy for `rx_snr`: absent means null and nothing else. Unlike [rxTimeOrNull], a zero must never
+ * be folded into "unknown" — 0 dB is a real measurement. See [snrOrNull].
+ *
+ * The proto-absent case is not asserted here because it is not yet constructible: `rx_snr` is still a non-null `float`
+ * upstream, so [snrOrNull] cannot return null for any packet this test could build. What these tests do lock down is
+ * the half that can regress today — that a zero is never folded — which is exactly what breaks if the [rxTimeOrNull]
+ * pattern is copied over. Null *handling* is covered where a null is representable: `MetricFormatterTest`
+ * (`snrAbsentIsUnknown`) and `LoraSignalIndicatorUiTest` (`snrRendersNothingWhenAbsent`,
+ * `loraSignalIndicatorShowsUnknownWhenSnrIsAbsent`).
+ */
+class SnrExtensionsTest {
+
+ private fun loraPacket(snr: Float) = MeshPacket(
+ rx_time = 1_700_000_000,
+ rx_snr = snr,
+ hop_start = 3,
+ hop_limit = 3,
+ transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA,
+ )
+
+ @Test
+ fun `snrOrNull reports a negative reading`() {
+ // The common case: SNR below the noise floor but still demodulable on a long preset.
+ assertEquals(-12.5f, loraPacket(-12.5f).snrOrNull())
+ }
+
+ @Test
+ fun `snrOrNull reports a positive reading`() {
+ assertEquals(6.5f, loraPacket(6.5f).snrOrNull())
+ }
+
+ @Test
+ fun `snrOrNull treats a true zero as a measurement rather than as absent`() {
+ // The whole point of the policy. A zero-folding `takeIf { it != 0f }` here would silently discard a signal
+ // sitting exactly at the noise floor — comfortably demodulable on every preset — and would also throw away
+ // the only zero that can reach us, since a zero from firmware predating the optional conversion is never put
+ // on the wire and so already decodes to null.
+ val snr = loraPacket(0f).snrOrNull()
+ assertNotNull(snr)
+ assertEquals(0f, snr)
+ }
+
+ @Test
+ fun `snrOrNull does not conflate a zero reading with an unknown one`() {
+ // Regression guard for the rx_time seam's pattern being copied over verbatim.
+ assertEquals(0f, loraPacket(0f).snrOrNull())
+ assertEquals(null, MeshPacket().rxTimeOrNull())
+ }
+}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt
index 5d672477c7..8905be8902 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt
@@ -37,7 +37,6 @@ import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
private const val MILLIS_PER_SECOND = 1000L
private const val MAX_BATTERY_PERCENT = 100
-private const val SNR_UNSET_THRESHOLD = 100f
/** Pre-resolved localized strings for TalkBack node descriptions. */
@Immutable
@@ -82,8 +81,7 @@ internal fun buildNodeDescription(
hopsAway: Int,
batteryLevel: Int?,
distance: String?,
- snr: Float,
- rssi: Int,
+ snr: Float?,
viaMqtt: Boolean,
strings: NodeDescriptionStrings,
lastHeardIsRelative: Boolean = true,
@@ -122,7 +120,9 @@ internal fun buildNodeDescription(
append(", ")
append(strings.distanceAway.replace("%s", it))
}
- if (hopsAway == 0 && !viaMqtt && snr < SNR_UNSET_THRESHOLD && rssi < 0) {
+ // Rated from SNR alone: RSSI cannot indicate demodulability without the noise floor, and the old `rssi < 0` gate
+ // suppressed the announcement for a genuine 0 dBm reading.
+ if (hopsAway == 0 && !viaMqtt && snr != null) {
val quality = determineSignalQuality(snr, modemPreset)
append(", ")
append(strings.signal.replace("%s", quality.name.lowercase()))
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt
index d01bbb9a48..3f55bb3b6e 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt
@@ -20,11 +20,7 @@ package org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ExperimentalLayoutApi
-import androidx.compose.foundation.layout.FlowRow
-import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
@@ -56,6 +52,7 @@ import org.meshtastic.core.resources.rssi
import org.meshtastic.core.resources.signal
import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.resources.snr
+import org.meshtastic.core.resources.unknown
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
@@ -88,60 +85,22 @@ enum class Quality(
GOOD(Res.string.good, Res.drawable.ic_signal_cellular_4_bar, { colorScheme.StatusGreen }),
}
-/**
- * Displays the `snr` and `rssi` color coded based on the signal quality, along with a human readable description and
- * related icon.
- */
-@OptIn(ExperimentalLayoutApi::class)
-@Composable
-fun NodeSignalQuality(
- snr: Float,
- rssi: Int?,
- modifier: Modifier = Modifier,
- modemPreset: ModemPreset? = LocalModemPreset.current,
-) {
- val quality = determineSignalQuality(snr, modemPreset)
- FlowRow(
- modifier = modifier,
- itemVerticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Snr(snr, modemPreset = modemPreset)
- Rssi(rssi)
- Text(
- text = "${stringResource(Res.string.signal)} ${stringResource(quality.nameRes)}",
- style = MaterialTheme.typography.labelSmall,
- maxLines = 1,
- )
- Icon(
- modifier = Modifier.size(SIZE_ICON_DP.dp),
- imageVector = vectorResource(quality.icon),
- contentDescription = stringResource(Res.string.signal_quality),
- tint = quality.color(),
- )
- }
-}
-
private const val SIZE_ICON_DP = 16
-/** Displays the `snr` and `rssi` with color depending on the values respectively. */
-@Composable
-fun SnrAndRssi(snr: Float, rssi: Int?, modemPreset: ModemPreset? = LocalModemPreset.current) {
- Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
- Snr(snr, modemPreset = modemPreset)
- Rssi(rssi)
- }
-}
-
-/** Displays a human readable description and icon representing the signal quality. */
+/**
+ * Displays a human readable description and icon representing the signal quality.
+ *
+ * A null [snr] means the packet carried no measurement, which is rendered as "Unknown" in a neutral tint. It must not
+ * fall through to [Quality.NONE] — that band means "measured, and too weak to demodulate", a different claim.
+ */
@Composable
fun LoraSignalIndicator(
- snr: Float,
+ snr: Float?,
modifier: Modifier = Modifier,
modemPreset: ModemPreset? = LocalModemPreset.current,
contentColor: Color = MaterialTheme.colorScheme.onSurface,
) {
- val quality = determineSignalQuality(snr, modemPreset)
+ val quality = snr?.let { determineSignalQuality(it, modemPreset) }
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
@@ -149,20 +108,22 @@ fun LoraSignalIndicator(
) {
Icon(
modifier = Modifier.size(SIZE_ICON_DP.dp),
- imageVector = vectorResource(quality.icon),
+ imageVector = vectorResource(quality?.icon ?: Res.drawable.ic_signal_cellular_alt),
contentDescription = stringResource(Res.string.signal_quality),
- tint = quality.color(),
+ tint = quality?.color?.invoke() ?: MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
- text = "${stringResource(Res.string.signal)} ${stringResource(quality.nameRes)}",
+ text = "${stringResource(Res.string.signal)} " + stringResource(quality?.nameRes ?: Res.string.unknown),
style = MaterialTheme.typography.labelSmall,
color = contentColor,
)
}
}
+/** Renders nothing when [snr] is absent — 0 dB is a real reading, so it must not stand in for "no reading". */
@Composable
-fun Snr(snr: Float, modifier: Modifier = Modifier, modemPreset: ModemPreset? = LocalModemPreset.current) {
+fun Snr(snr: Float?, modifier: Modifier = Modifier, modemPreset: ModemPreset? = LocalModemPreset.current) {
+ if (snr == null) return
val color: Color = determineSignalQuality(snr, modemPreset).color.invoke()
Text(
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt
index 5d0708cb2d..41577b3a9f 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt
@@ -145,8 +145,7 @@ fun NodeItem(
hopsAway = thatNode.hopsAway,
batteryLevel = thatNode.batteryLevel,
distance = distance,
- snr = thatNode.snr,
- rssi = thatNode.rssi,
+ snr = thatNode.snrOrNull,
viaMqtt = thatNode.viaMqtt,
strings = a11yStrings,
modemPreset = modemPreset,
@@ -312,9 +311,9 @@ private fun NodeSignalRow(thatNode: Node, isThisNode: Boolean, contentColor: Col
if (thatNode.hopsAway > 0) {
add { HopsInfo(hops = thatNode.hopsAway, contentColor = contentColor) }
} else if (thatNode.hopsAway == 0 && !thatNode.viaMqtt) {
- val showSnr = thatNode.snr < 100f
- val showRssi = thatNode.rssi < 0
- if (showSnr || showRssi) {
+ val snr = thatNode.snrOrNull
+ val rssi = thatNode.rssiOrNull
+ if (snr != null || rssi != null) {
signalChip = {
// Full-width row: SNR left, RSSI center, quality right.
Row(
@@ -322,10 +321,10 @@ private fun NodeSignalRow(thatNode: Node, isThisNode: Boolean, contentColor: Col
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
- if (showSnr) Snr(thatNode.snr)
- if (showRssi) Rssi(thatNode.rssi)
- if (showSnr && showRssi) {
- val quality = determineSignalQuality(thatNode.snr, LocalModemPreset.current)
+ Snr(snr)
+ Rssi(rssi)
+ if (snr != null) {
+ val quality = determineSignalQuality(snr, LocalModemPreset.current)
IconInfo(
icon = vectorResource(quality.icon),
contentDescription = stringResource(Res.string.signal_quality),
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt
index 25d7b5962b..bec0f1458d 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt
@@ -155,8 +155,7 @@ fun NodeItemCompact(
hopsAway = thatNode.hopsAway,
batteryLevel = thatNode.batteryLevel,
distance = distance,
- snr = thatNode.snr,
- rssi = thatNode.rssi,
+ snr = thatNode.snrOrNull,
viaMqtt = thatNode.viaMqtt,
strings = a11yStrings,
lastHeardIsRelative = lastHeardIsRelative,
@@ -350,10 +349,10 @@ private fun CompactHealthRow(
)
}
- // Signal quality
- val hasDirectSignal = thatNode.hopsAway == 0 && thatNode.snr < 100f && !thatNode.viaMqtt && thatNode.rssi < 0
- if (showSignal && hasDirectSignal) {
- val quality = determineSignalQuality(thatNode.snr, LocalModemPreset.current)
+ // Signal quality, rated from SNR alone — RSSI is not part of the rating (#5446), so it must not gate it.
+ val directSnr = thatNode.snrOrNull?.takeIf { thatNode.hopsAway == 0 && !thatNode.viaMqtt }
+ if (showSignal && directSnr != null) {
+ val quality = determineSignalQuality(directSnr, LocalModemPreset.current)
add(
@Composable {
IconInfo(
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt
index 6e41b23540..6d1c008910 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt
@@ -42,17 +42,21 @@ import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.LocalModemPreset
-const val MAX_VALID_SNR = 100F
-const val MAX_VALID_RSSI = 0
-
+/**
+ * Renders the node's signal quality, or nothing when it has no SNR reading to rate.
+ *
+ * Presence comes from [Node.snrOrNull]/[Node.rssiOrNull], not from threshold comparisons: the previous `rssi < 0` gate
+ * hid the whole row for a genuine 0 dBm reading, and `snr < 100f` would have hidden any reading at or above 100 dB.
+ */
@Composable
fun SignalInfo(
modifier: Modifier = Modifier,
node: Node,
@Suppress("UNUSED_PARAMETER") contentColor: Color = MaterialTheme.colorScheme.onSurface,
) {
- if (node.snr < MAX_VALID_SNR && node.rssi < MAX_VALID_RSSI) {
- val quality = determineSignalQuality(node.snr, LocalModemPreset.current)
+ val snr = node.snrOrNull
+ if (snr != null) {
+ val quality = determineSignalQuality(snr, LocalModemPreset.current)
val signalColor = quality.color.invoke()
Row(
modifier = modifier,
@@ -67,9 +71,8 @@ fun SignalInfo(
)
Text(
text =
- "${MetricFormatter.snr(
- node.snr,
- )} · ${MetricFormatter.rssi(node.rssi)} · ${stringResource(quality.nameRes)}",
+ "${MetricFormatter.snr(snr)} · ${MetricFormatter.rssi(node.rssiOrNull)} · " +
+ stringResource(quality.nameRes),
style =
MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,
diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/BuildNodeDescriptionTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/BuildNodeDescriptionTest.kt
index cc6f03e3e1..9e46266465 100644
--- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/BuildNodeDescriptionTest.kt
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/BuildNodeDescriptionTest.kt
@@ -48,8 +48,7 @@ class BuildNodeDescriptionTest {
hopsAway: Int = 0,
batteryLevel: Int? = null,
distance: String? = null,
- snr: Float = Float.MAX_VALUE,
- rssi: Int = 0,
+ snr: Float? = null,
viaMqtt: Boolean = false,
lastHeardIsRelative: Boolean = true,
): String = buildNodeDescription(
@@ -62,7 +61,6 @@ class BuildNodeDescriptionTest {
batteryLevel = batteryLevel,
distance = distance,
snr = snr,
- rssi = rssi,
viaMqtt = viaMqtt,
strings = testStrings,
lastHeardIsRelative = lastHeardIsRelative,
@@ -157,32 +155,33 @@ class BuildNodeDescriptionTest {
// ---- Signal ----
@Test
- fun signal_hidden_when_snr_is_max_float() {
- val result = describe(snr = Float.MAX_VALUE, rssi = -100, hopsAway = 0, viaMqtt = false)
+ fun signal_hidden_when_snr_is_absent() {
+ val result = describe(snr = null, hopsAway = 0, viaMqtt = false)
assertFalse(result.contains("signal"))
}
@Test
fun signal_hidden_when_via_mqtt() {
- val result = describe(snr = -5f, rssi = -100, hopsAway = 0, viaMqtt = true)
+ val result = describe(snr = -5f, hopsAway = 0, viaMqtt = true)
assertFalse(result.contains("signal"))
}
@Test
fun signal_hidden_when_hops_greater_than_zero() {
- val result = describe(snr = -5f, rssi = -100, hopsAway = 1, viaMqtt = false)
+ val result = describe(snr = -5f, hopsAway = 1, viaMqtt = false)
assertFalse(result.contains("signal"))
}
@Test
- fun signal_hidden_when_rssi_not_negative() {
- val result = describe(snr = -5f, rssi = 0, hopsAway = 0, viaMqtt = false)
- assertFalse(result.contains("signal"))
+ fun signal_shown_for_a_zero_snr_reading() {
+ // 0 dB is a real, strong reading. It was previously announced only when RSSI happened to be negative.
+ val result = describe(snr = 0f, hopsAway = 0, viaMqtt = false)
+ assertContains(result, "signal")
}
@Test
fun signal_shown_when_direct_and_valid_values() {
- val result = describe(snr = -5f, rssi = -100, hopsAway = 0, viaMqtt = false)
+ val result = describe(snr = -5f, hopsAway = 0, viaMqtt = false)
assertContains(result, "signal")
}
}
diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorTest.kt
index 1ce0b45575..acee09c70c 100644
--- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorTest.kt
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorTest.kt
@@ -77,6 +77,22 @@ class LoraSignalIndicatorTest {
assertEquals(Quality.NONE, determineSignalQuality(snr = -30f, modemPreset = preset)) // < limit-7.5
}
+ @Test
+ fun `a zero SNR reading is rated rather than treated as missing`() {
+ // 0 dB sits well above every preset's demod floor, so it is an excellent signal — not an absent one. If a
+ // presence check ever folds zero into "unknown", this is the reading that disappears.
+ assertEquals(Quality.GOOD, determineSignalQuality(snr = 0f, modemPreset = ModemPreset.LONG_FAST))
+ assertEquals(Quality.GOOD, determineSignalQuality(snr = 0f, modemPreset = ModemPreset.SHORT_FAST))
+ }
+
+ @Test
+ fun `absent SNR is not a quality band`() {
+ // Quality has no member for "no measurement": callers must pass a non-null SNR, and the composables render
+ // absence as Unknown rather than mapping it onto NONE (which asserts a measured, undemodulable signal).
+ assertEquals(4, Quality.entries.size)
+ assertEquals(listOf(Quality.NONE, Quality.BAD, Quality.FAIR, Quality.GOOD), Quality.entries.toList())
+ }
+
@Test
fun `RSSI does not influence the rating`() {
// Identical SNR + preset always yields the same verdict regardless of any RSSI (RSSI is display-only now).
diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorUiTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorUiTest.kt
index 0a9317fd16..6f995dda27 100644
--- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorUiTest.kt
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorUiTest.kt
@@ -34,6 +34,29 @@ class LoraSignalIndicatorUiTest {
onNodeWithText("Signal strength -70 dBm").assertIsDisplayed()
}
+ @Test
+ fun snrRendersAZeroReading() = runComposeUiTest {
+ // 0 dB is a measurement and must be shown, not suppressed as "no reading".
+ setContent { AppTheme { Snr(snr = 0f) } }
+
+ onNodeWithText("SNR 0.00 dB").assertIsDisplayed()
+ }
+
+ @Test
+ fun snrRendersNothingWhenAbsent() = runComposeUiTest {
+ setContent { AppTheme { Snr(snr = null) } }
+
+ onNodeWithText("SNR 0.00 dB").assertDoesNotExist()
+ }
+
+ @Test
+ fun loraSignalIndicatorShowsUnknownWhenSnrIsAbsent() = runComposeUiTest {
+ // Absence must not render as "Signal None" — that band means a measured, undemodulable signal.
+ setContent { AppTheme { LoraSignalIndicator(snr = null) } }
+
+ onNodeWithText("Signal Unknown").assertIsDisplayed()
+ }
+
@Test
fun batteryUsesCallerProvidedUnknownLabel() = runComposeUiTest {
setContent { AppTheme { MaterialBatteryInfo(level = null, unknownLabel = "Unavailable") } }
diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/model/CarUiModels.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/model/CarUiModels.kt
index b72d2615e2..63019ebf62 100644
--- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/model/CarUiModels.kt
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/model/CarUiModels.kt
@@ -63,7 +63,12 @@ enum class SignalQuality {
GOOD,
FAIR,
BAD,
+
+ /** Measured, but too weak to demodulate. Distinct from [UNKNOWN]. */
NONE,
+
+ /** No SNR reading for this node, so link quality cannot be rated. */
+ UNKNOWN,
}
data class TopologyHeader(val totalNodes: Int, val onlineNodes: Int, val meshName: String?)
diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDetailScreen.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDetailScreen.kt
index 231f78597a..7a34dd934d 100644
--- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDetailScreen.kt
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDetailScreen.kt
@@ -105,6 +105,7 @@ class NodeDetailScreen(
SignalQuality.FAIR -> carContext.getString(R.string.car_signal_fair)
SignalQuality.BAD -> carContext.getString(R.string.car_signal_bad)
SignalQuality.NONE -> carContext.getString(R.string.car_signal_none)
+ SignalQuality.UNKNOWN -> carContext.getString(R.string.car_signal_unknown)
}
private fun formatLastHeard(epochMillis: Long): String {
diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilder.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilder.kt
index 17666c85bb..a9069ce5ea 100644
--- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilder.kt
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilder.kt
@@ -53,7 +53,7 @@ internal object CarScreenDataBuilder {
userId = node.user.id,
longName = node.user.long_name.ifEmpty { "Unknown" },
shortName = node.user.short_name.ifEmpty { "?" },
- signalQuality = determineSignalQuality(node.snr, modemPreset),
+ signalQuality = determineSignalQuality(node.snrOrNull, modemPreset),
batteryPercent = node.batteryLevel?.takeIf { it in 1..BATTERY_MAX_PERCENT },
isOnline = node.isOnline,
lastHeard = node.lastHeard.toLong() * SECONDS_TO_MILLIS,
@@ -72,9 +72,12 @@ internal object CarScreenDataBuilder {
/**
* Determines signal quality from SNR relative to the modem preset's demodulation floor ([ModemPreset.snrLimit]).
* RSSI is not used (matching core/ui); a null/unknown preset falls back to the LongFast default limit.
+ *
+ * A null [snr] means no reading and yields [SignalQuality.UNKNOWN], never [SignalQuality.NONE] — the latter claims
+ * a measured, undemodulable link. 0 dB is a real reading and rates normally.
*/
- fun determineSignalQuality(snr: Float, modemPreset: ModemPreset? = null): SignalQuality {
- if (snr == Float.MAX_VALUE) return SignalQuality.NONE
+ fun determineSignalQuality(snr: Float?, modemPreset: ModemPreset? = null): SignalQuality {
+ if (snr == null) return SignalQuality.UNKNOWN
val limit = modemPreset.snrLimit
return when {
snr > limit + SNR_EXCELLENT_MARGIN -> SignalQuality.EXCELLENT
diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/NodeSubtitleFormatter.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/NodeSubtitleFormatter.kt
index f9f026fd40..3bd06cc439 100644
--- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/NodeSubtitleFormatter.kt
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/NodeSubtitleFormatter.kt
@@ -70,6 +70,7 @@ object NodeSubtitleFormatter {
SignalQuality.FAIR -> context.getString(R.string.car_signal_fair)
SignalQuality.BAD -> context.getString(R.string.car_signal_bad)
SignalQuality.NONE -> context.getString(R.string.car_signal_none)
+ SignalQuality.UNKNOWN -> context.getString(R.string.car_signal_unknown)
}
fun signalColor(quality: SignalQuality): CarColor = when (quality) {
@@ -78,5 +79,6 @@ object NodeSubtitleFormatter {
SignalQuality.FAIR -> CarColor.YELLOW
SignalQuality.BAD -> CarColor.RED
SignalQuality.NONE -> CarColor.SECONDARY
+ SignalQuality.UNKNOWN -> CarColor.SECONDARY
}
}
diff --git a/feature/car/src/main/res/values/strings.xml b/feature/car/src/main/res/values/strings.xml
index 5065098c9c..b36913a965 100644
--- a/feature/car/src/main/res/values/strings.xml
+++ b/feature/car/src/main/res/values/strings.xml
@@ -19,6 +19,7 @@
<string name="car_signal_fair">Fair</string>
<string name="car_signal_good">Good</string>
<string name="car_signal_none">None</string>
+ <string name="car_signal_unknown">Unknown</string>
<string name="car_status_battery">Battery</string>
<string name="car_status_last_heard">Last Heard</string>
<string name="car_status_offline">Offline</string>
diff --git a/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilderTest.kt b/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilderTest.kt
index e248697478..7265536f83 100644
--- a/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilderTest.kt
+++ b/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilderTest.kt
@@ -56,10 +56,38 @@ class CarScreenDataBuilderTest {
// determineSignalQuality() — preset-relative SNR, RSSI not used (issue #5446)
@Test
- fun `determineSignalQuality returns none when snr is max value`() {
- val quality = CarScreenDataBuilder.determineSignalQuality(Float.MAX_VALUE, ModemPreset.LONG_FAST)
+ fun `determineSignalQuality returns unknown when snr is absent`() {
+ // Absence is UNKNOWN, not NONE: NONE claims a measured link too weak to demodulate.
+ val quality = CarScreenDataBuilder.determineSignalQuality(null, ModemPreset.LONG_FAST)
- assertEquals(SignalQuality.NONE, quality)
+ assertEquals(SignalQuality.UNKNOWN, quality)
+ }
+
+ @Test
+ fun `determineSignalQuality rates a zero snr reading`() {
+ val quality = CarScreenDataBuilder.determineSignalQuality(0f, ModemPreset.LONG_FAST)
+
+ assertEquals(SignalQuality.EXCELLENT, quality)
+ }
+
+ @Test
+ fun `buildNodeUi resolves an unset node snr to unknown`() {
+ // Exercises the production path: fails if buildNodeUi reverts to reading node.snr, which would feed the
+ // Float.MAX_VALUE sentinel into the bands and rate a node with no reading as EXCELLENT.
+ val node = Node(num = 1)
+
+ val ui = CarScreenDataBuilder.buildNodeUi(node, ModemPreset.LONG_FAST)
+
+ assertEquals(SignalQuality.UNKNOWN, ui.signalQuality)
+ }
+
+ @Test
+ fun `buildNodeUi rates a zero node snr reading`() {
+ val node = Node(num = 1, snr = 0f)
+
+ val ui = CarScreenDataBuilder.buildNodeUi(node, ModemPreset.LONG_FAST)
+
+ assertEquals(SignalQuality.EXCELLENT, ui.signalQuality)
}
@Test
diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt
index 4869d2df26..505acc73da 100644
--- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt
+++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt
@@ -46,6 +46,7 @@ import org.meshtastic.core.model.ChannelOption
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.decodeOrNull
+import org.meshtastic.core.model.util.snrOrNull
import org.meshtastic.core.repository.DiscoveryPacketCollector
import org.meshtastic.core.repository.DiscoveryPacketCollectorRegistry
import org.meshtastic.core.repository.MeshPrefs
@@ -267,8 +268,8 @@ class DiscoveryScanEngine(
mutex.withLock {
val node = collectedNodes.getOrPut(fromNum) { CollectedNodeData(nodeNum = fromNum) }
// Update signal info from the direct packet
- if (meshPacket.rx_snr != 0f) node.snr = meshPacket.rx_snr
- // Explicit presence: record a reported 0 dBm, skip only a genuinely absent one.
+ // Explicit presence: record a reported 0 dB/0 dBm, skip only a genuinely absent one.
+ meshPacket.snrOrNull()?.let { node.snr = it }
meshPacket.rx_rssi?.let { node.rssi = it }
node.hopCount = dataPacket.hopsAway.coerceAtLeast(0)
diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/DiscoveryReportFormatter.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/DiscoveryReportFormatter.kt
index e301850d97..1eb5d967ad 100644
--- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/DiscoveryReportFormatter.kt
+++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/DiscoveryReportFormatter.kt
@@ -58,7 +58,7 @@ internal object DiscoveryReportFormatter {
fun formatNodeLine(node: DiscoveredNodeEntity): String = buildString {
append(node.longName ?: node.shortName ?: "!${node.nodeNum.toString(radix = 16)}")
append(" | ${node.neighborType}")
- append(" | SNR: ${NumberFormatter.format(node.snr, 1)}")
+ append(" | SNR: ${MetricFormatter.snr(node.snr)}")
append(" | RSSI: ${MetricFormatter.rssi(node.rssi)}")
val distance = node.distanceFromUser
if (distance != null) {
diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/MeshBeaconInvitationCard.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/MeshBeaconInvitationCard.kt
index 330c6c53ff..315709b661 100644
--- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/MeshBeaconInvitationCard.kt
+++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/MeshBeaconInvitationCard.kt
@@ -99,7 +99,7 @@ internal fun MeshBeaconInvitationCard(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
- if (offer.rssi != null || offer.snr != 0f) {
+ if (offer.rssi != null || offer.snr != null) {
Text(
text =
stringResource(
diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt
index 1d8e41feab..7578f2d3c0 100644
--- a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt
+++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt
@@ -74,6 +74,68 @@ class MessageItemTest {
onNodeWithContentDescription("MQTT").assertIsDisplayed()
}
+ @Test
+ fun directMessageWithoutSnrDoesNotFabricateAZeroReading() = runComposeUiTest {
+ // Before DataPacket/Message.snr became nullable, an absent SNR narrowed to 0f on the way through the mapper
+ // and this row rendered "SNR 0.00 dB" — a measurement the radio never took.
+ val testNode = NodePreviewParameterProvider().minnieMouse
+ val message = directMessage(node = testNode, snr = null)
+
+ setContent {
+ MessageItem(
+ message = message,
+ node = testNode,
+ selected = false,
+ onClick = {},
+ onLongClick = {},
+ onStatusClick = {},
+ ourNode = testNode,
+ )
+ }
+
+ onNodeWithText("SNR 0.00 dB", useUnmergedTree = true).assertDoesNotExist()
+ }
+
+ @Test
+ fun directMessageWithZeroSnrShowsTheReading() = runComposeUiTest {
+ // The other half: 0 dB is a real, strong reading and must still render.
+ val testNode = NodePreviewParameterProvider().minnieMouse
+ val message = directMessage(node = testNode, snr = 0f)
+
+ setContent {
+ MessageItem(
+ message = message,
+ node = testNode,
+ selected = false,
+ onClick = {},
+ onLongClick = {},
+ onStatusClick = {},
+ ourNode = testNode,
+ )
+ }
+
+ onNodeWithText("SNR 0.00 dB", useUnmergedTree = true).assertIsDisplayed()
+ }
+
+ private fun directMessage(node: Node, snr: Float?) = Message(
+ text = "Direct message",
+ time = "10:00",
+ fromLocal = false,
+ status = MessageStatus.RECEIVED,
+ snr = snr,
+ rssi = -90,
+ hopsAway = 0,
+ uuid = 1L,
+ receivedTime = nowMillis,
+ node = node,
+ read = false,
+ routingError = 0,
+ packetId = 1234,
+ emojis = listOf(),
+ replyId = null,
+ viaMqtt = false,
+ )
+
@Test
fun mqttIconIsNotDisplayedWhenViaMqttIsFalse() = runComposeUiTest {
val testNode = NodePreviewParameterProvider().minnieMouse
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt
index 96506f7379..c2d1f7160d 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt
@@ -290,20 +290,22 @@ private fun UserAndUptimeRow(node: Node) {
@Composable
private fun SignalRow(node: Node) {
Row(modifier = Modifier.fillMaxWidth()) {
- if (node.snr != Float.MAX_VALUE) {
+ val snr = node.snrOrNull
+ if (snr != null) {
InfoItem(
label = stringResource(Res.string.snr),
- value = MetricFormatter.snr(node.snr),
+ value = MetricFormatter.snr(snr),
icon = MeshtasticIcons.Snr,
modifier = Modifier.weight(1f),
)
} else {
Spacer(Modifier.weight(1f))
}
- if (node.rssi != Int.MAX_VALUE) {
+ val rssi = node.rssiOrNull
+ if (rssi != null) {
InfoItem(
label = stringResource(Res.string.rssi),
- value = MetricFormatter.rssi(node.rssi),
+ value = MetricFormatter.rssi(rssi),
icon = MeshtasticIcons.Rssi,
modifier = Modifier.weight(1f),
)
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
index 1031f8167d..a29bce2f70 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
@@ -50,6 +50,7 @@ import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.util.GeoConstants
import org.meshtastic.core.model.util.UnitConversions
import org.meshtastic.core.model.util.rxTimeOrNull
+import org.meshtastic.core.model.util.snrOrNull
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
@@ -455,8 +456,9 @@ open class MetricsViewModel(
rows = data,
epochSeconds = { (it.rxTimeOrNull() ?: 0).toLong() },
) { p ->
- // An absent rssi exports as an empty field, matching the other optional metrics above.
- "\"${p.rx_rssi ?: ""}\",\"${p.rx_snr}\""
+ // An absent rssi or snr exports as an empty field, matching the other optional metrics above. An empty
+ // field and "0" must stay distinguishable: 0 dB is a real reading.
+ "\"${p.rx_rssi ?: ""}\",\"${p.snrOrNull() ?: ""}\""
}
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt
index 8a317745e3..9c1cf6d50f 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt
@@ -59,6 +59,7 @@ import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.model.util.rxTimeOrNull
+import org.meshtastic.core.model.util.snrOrNull
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.busy_noise_floor
import org.meshtastic.core.resources.clear
@@ -164,7 +165,7 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit, m
val data = remember(signalData, localStatsData) { buildSignalLog(signalData, localStatsData) }
val hasNoiseFloor = remember(localStatsData) { localStatsData.any { it.local_stats?.noise_floor != 0 } }
val hasRssi = remember(signalData) { signalData.any { it.rx_rssi != null } }
- val hasSnr = remember(signalData) { signalData.any { !it.rx_snr.isNaN() } }
+ val hasSnr = remember(signalData) { signalData.any { it.snrOrNull() != null } }
val hasAnyLocalStats = state.localStats.isNotEmpty()
val localStatsExportLauncher = rememberSaveFileLauncher { uri -> viewModel.saveLocalStatsCSV(uri, localStatsData) }
val signalExportLauncher = rememberSaveFileLauncher { uri -> viewModel.saveSignalMetricsCSV(uri, signalData) }
@@ -319,7 +320,7 @@ private fun SignalMetricsChart(
if (noiseFloorData.size > 1) listOf(noiseFloorData.first(), noiseFloorData.last()) else emptyList()
}
val rssiData = remember(meshPackets) { meshPackets.filter { it.rx_rssi != null } }
- val snrData = remember(meshPackets) { meshPackets.filter { !it.rx_snr.isNaN() } }
+ val snrData = remember(meshPackets) { meshPackets.filter { it.snrOrNull() != null } }
val legendData =
remember(noiseFloorData, rssiData, snrData) {
LEGEND_DATA.filter { legend ->
@@ -366,7 +367,9 @@ private fun SignalMetricsChart(
}
if (snrData.isNotEmpty()) {
/* Use a separate lineModel call to associate SNR with the right axis. */
- lineModel { series(x = snrData.map { it.rxTimeOrNull() ?: 0 }, y = snrData.map { it.rx_snr }) }
+ lineModel {
+ series(x = snrData.map { it.rxTimeOrNull() ?: 0 }, y = snrData.mapNotNull { it.snrOrNull() })
+ }
}
}
}
@@ -583,14 +586,17 @@ private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onCli
Row(verticalAlignment = Alignment.CenterVertically) {
MetricValueRow(color = SignalMetric.RSSI.color, text = MetricFormatter.rssi(meshPacket.rx_rssi))
Spacer(Modifier.width(12.dp))
- MetricValueRow(color = SignalMetric.SNR.color, text = MetricFormatter.snr(meshPacket.rx_snr))
+ MetricValueRow(
+ color = SignalMetric.SNR.color,
+ text = MetricFormatter.snr(meshPacket.snrOrNull()),
+ )
}
}
}
/* Signal Indicator */
Box(modifier = Modifier.weight(weight = 3f).height(IntrinsicSize.Max)) {
- LoraSignalIndicator(snr = meshPacket.rx_snr)
+ LoraSignalIndicator(snr = meshPacket.snrOrNull())
}
}
}
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt
index e30bf6603e..989b3620c8 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt
@@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.DateFormatter
+import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowInstant
import org.meshtastic.core.database.entity.Packet
@@ -548,7 +549,9 @@ class DebugViewModel(
if (info.neighbors.isNotEmpty()) {
appendLine(" neighbors:")
info.neighbors.forEach {
- appendLine(" - node_id: ${formatNodeWithShortName(it.node_id)} snr: ${it.snr}")
+ appendLine(
+ " - node_id: ${formatNodeWithShortName(it.node_id)} snr: ${MetricFormatter.snr(it.snr)}",
+ )
}
}
}
Served by rngit 1.5.0 - Generated in 0.89s